配合分支:day27/start,Repo: 按我
直接進入主題,昨天新增了兩種輸出格式:純文字、Markdown。但從 DiaryExporter 來看,未免也過於複雜了。是想像未來新增更多的格式,底下在組成輸出文字後,在 when 標籤就會越來越多。而且,如果未來越來越複雜,裡面的判斷應該會變得很嚇人。
今天來處理這個 Formater 未來可能暴增的問題,解方?interface。
就不在 DiaryExporter 裡頭做組出最終結果字串。用 interface 讓實作的人自行實作即可。
以目前的狀況來說,需要有兩個 Formatter:
兩者實作同一套契約,透過之前學過的里氏替代法則(Liskov Substitution Principle),把 interface 傳入 DiaryExporter 讓它執行契約的方法就好(他不需要知道後面怎麼做的,只要有回傳 String 回來就可以交差了事)。
先來處理這個契約。
interface DiaryFormatter {
// 👇🏼 是的 interface 也可以要求要有「變數」
val displayName: String // 👈🏼 提供這個 formatter 的名字
fun format(entries: List<DiaryEntry>): String // 👈🏼 本章重點
}
Okay!接下來需要兩份 Formatter,分別處理上述提到的兩個格式。
class PlainTextDiaryFormatter : DiaryFormatter {
override val displayName: String = "純文字"
override fun format(entries: List<DiaryEntry>): String {
return entries.joinToString(separator = "\n\n") { diaryEntry ->
"${diaryEntry.title}\n${diaryEntry.note}"
}
}
}
class MarkdownDiaryFormatter : DiaryFormatter {
override val displayName: String = "Markdown"
override fun format(entries: List<DiaryEntry>): String {
return entries.joinToString(separator = "\n\n") { diaryEntry ->
// 👇🏼 多了一個井字號 ㄏ
"# ${diaryEntry.title}\n${diaryEntry.note}"
}
}
}
如此一來,透過 class 的 type 就可以判斷這是哪一種格式了!(再不行,displayName 也可以判斷)。也就是說 DiaryExportFormat 這個 enum class 可以退場移除了,已不再需要。
程式碼變得超級簡單:
// 👇🏼 丟 interface 即可
fun export(formatter: DiaryFormatter): String {
val loadedEntries = diaryStore.load()
if (loadedEntries == null || loadedEntries.isEmpty()) {
return "目前沒有已儲存的日記"
}
return formatter.format(loadedEntries) // 👈🏼 format 由個自實作
}
從程式碼來看,enum class 已經不使用,外部從 UI 選擇改選「Formatter」。先調整 ViewModel 糨糊層,可以先將可用的 Formatter 格式注進去。

接著 ViewModel 工廠、MainActivity 需要調整。

MainActivity 建立 ViewModel 之處,跟著更新。
private val photoDiaryViewModel: PhotoDiaryViewModel by viewModels {
val diaryStore = LocalDiaryStorage(applicationContext)
val diaryExporter = DiaryExporter(diaryStore)
// 👇🏼 塞入各類的 Formatter
val diaryFormatters: List<DiaryFormatter> = listOf(
PlainTextDiaryFormatter(),
MarkdownDiaryFormatter()
)
// 🤗 注入工廠
PhotoDiaryViewModelFactory(diaryStore, diaryExporter, diaryFormatters)
}
這樣資料層就完成了,最後來調整 UI:
該改的地方就集中在 PhotoDiaryApp.kt 一個檔案,資料流和 Day 22 做心情過濾時一模一樣:ViewModel 給狀態,ComposeView 拿到後畫出來,使用者點了什麼再用 callback function 丟回去。差別只在這次傳來傳去的東西,從 enum 變成了 DiaryFormatter。
先從最外層的 PhotoDiaryApp 開始。昨天只傳「選了哪個格式」,今天要多傳一份「有哪些可以選」:
// before
selectedExportFormat = viewModel.selectedExportFormat,
onExportFormatChange = { format -> viewModel.updateExportFormat(format) },
// after
availableFormatters = viewModel.availableFormatters, // 👈🏼 多了這個
selectedFormatter = viewModel.selectedFormatter,
onFormatterChange = { formatter -> viewModel.updateFormatter(formatter) },
DiaryList 的參數跟著改名,這邊只是把水管接上,沒什麼新東西。

重點在下拉選單。昨天的 ExportFormatDropdown 選項是直接去問 enum 的 DiaryExportFormat.entries,enum 都被砍了,當然要換人問:
@Composable
private fun FormatterDropdown(
availableFormatters: List<DiaryFormatter>, // 👈🏼 選項改由外面給
selectedFormatter: DiaryFormatter,
onFormatterChange: (DiaryFormatter) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box {
Button(
onClick = { expanded = true }
) {
// 👇🏼 interface 有規定要給 displayName,這裡直接拿來用
Text(text = "預覽格式:${selectedFormatter.displayName}")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
// 👇🏼 以前是 DiaryExportFormat.entries,現在 List 裡有幾個就列幾個
for (formatterOption in availableFormatters) {
DropdownMenuItem(
text = { Text(text = formatterOption.displayName) },
onClick = {
expanded = false
onFormatterChange(formatterOption)
}
)
}
}
}
}
有沒有發現,這個下拉選單從頭到尾不知道「純文字」和「Markdown」的存在?它只知道手上有一個 List,裡頭每一個都保證有 displayName 可以顯示,點下去就把那一個丟回去,交差。這也是為什麼一開始 interface 硬要多塞一個 val displayName,不然 UI 這邊就得自己想辦法生名字,大概會長成 when (formatter) { is PlainTextDiaryFormatter -> "純文字" ... } 這樣,不就又回到最初的起點嗎 =. =
最後是預覽對話框的標題,昨天拿的是 enum 的 displayName,今天換成 Formatter 的:
@Composable
private fun DiaryPreviewDialog(
previewText: String,
formatter: DiaryFormatter, // 👈🏼 從 DiaryExportFormat 改成 DiaryFormatter
onClose: () -> Unit
) {
AlertDialog(
onDismissRequest = { onClose() },
title = {
Text(text = "已儲存日記預覽(${formatter.displayName})")
},
// ... 省略
)
}
在 PhotoDiaryApp 呼叫它的地方,把 viewModel.selectedFormatter 傳進去就完成了。跑起來跟昨天長得一模一樣,選單一樣兩個選項,預覽出來的文字也一字不差:
P.S:測試裡頭建構 ViewModel 的地方,也要跟著多塞一個 listOf(PlainTextDiaryFormatter(), MarkdownDiaryFormatter()),和 Day 26 一樣請各位自行調整。(直接去 Repo 複製也行!)
目前為止只是把東西搬家而已,還沒證明這樣搬到底有什麼好處。真正的考驗是:加第三種格式的時候,要動幾個檔案?
配合分支:day27/formatters-separated,Repo: 按我
貪婪の me 決定要再加一個「摘要」Formatter,只萃取各個照片日記的標題。經過本次的超魔改之後變得超容易,只需要新增一個實作 DiaryFormatter 的 class,把想要輸出的格式實作於 format(),一切就做完啦!94 這麽簡單!(ViewModel/UI 層已經完全透過 DiaryFormatter 提供的資訊進行調整過了)
class SummaryDiaryFormatter : DiaryFormatter {
override val displayName: String = "摘要"
override fun format(entries: List<DiaryEntry>): String {
return entries.joinToString(separator = "\n") { diaryEntry ->
diaryEntry.title
}
}
}
剩下臨門一腳就是將這個 Formatter 注入到 ViewModel 之中,ViewModel 由工廠而來,而工廠的建造地址在 MainActivity。
val diaryFormatters: List<DiaryFormatter> = listOf(
PlainTextDiaryFormatter(),
MarkdownDiaryFormatter(),
SummaryDiaryFormatter() // 👈🏼 加入即可
)
PhotoDiaryViewModelFactory(diaryStore, diaryExporter, diaryFormatters)

今天做的事情其實有個名字,叫做開放封閉原則。經典的說法是:對擴充開放,對修改封閉(open for extension, closed for modification)。乍聽之下很玄,翻成白話就是:需求來了,用「新增」的方式應付,不用「改舊程式碼」。
用今天的例子對照一下就很清楚。先回想昨天的做法,如果要加「摘要」格式需要:
DiaryExportFormat enum 加一個 SUMMARY
DiaryExporter 的 when 多加一個分支也就是每加一種格式,就要把 DiaryExporter 拆開來動調整。今天調整後呢?總共只動兩個檔案:
SummaryDiaryFormatter.kt —— 新增的MainActivity.kt —— listOf 多一行DiaryExporter、DiaryFormatter、原本的兩個 Formatter、ViewModel、工廠、PhotoDiaryApp,全部沒改!這就是「對修改封閉」。
而「對擴充開放」就是那個 SummaryDiaryFormatter,想要新格式?寫一個新 class 實作 DiaryFormatter 就好。
聽到「對修改封閉」,很多人第一個反應是:所以程式寫好就不用再改了?錯!這個職場是不會放過我們的。今天 MainActivity 那個 listOf 還是多了一行。而且今天擋得住的只有「又要加一種格式」這件事而已,哪天 DiaryEntry 多了一個資料要印出來,三個 Formatter 照樣全部要改。
那不就跟 Day 19 說的「預先做的最佳化是萬惡之源」打架了嗎?並沒有!
Day 19 是說不要看到幾個 if 就去大改;今天則是 when 已經有兩個分支,第三個都排隊排到門口了,這時候抽 interface 是講得出理由的。反過來說,如果照片日記這輩子就只會有純文字一種輸出,那今天這一整篇都是超前部署,關掉 Android Studio 下班比較實在。
val:契約也可以要求「變數」,像 val displayName,讓 UI 保證拿得到名字,不必自己寫 when 生出來。DiaryExporter,它只管呼叫 format() 拿回 String 交差,不用知道背後是哪個實作。displayName)就能分辨格式,DiaryExportFormat 這個 enum class 可以刪掉。when 暴增是訊號:格式一多、when 分支會長得很嚇人,這時候抽 interface 才講得出理由,而不是看到幾個 if 就大改!切記無三不成禮。SummaryDiaryFormatter,listOf 多一行,其餘全沒改。